<?php

/**
 * 微信推送相关的工具类
 */
class Wechat{
    // 消息类型常量
    const MSG_TYPE_TEXT = 'text';
    const MSG_TYPE_NEWS = 'news';
    const MSG_TYPE_LINK = 'link';
    const MSG_TYPE_IMAGE = 'image';
    const MSG_TYPE_VOICE = 'voice';
    const MSG_TYPE_VIDEO = 'video';
    const MSG_TYPE_MUSIC = 'music';
    const MSG_TYPE_EVENT = 'event';
    const MSG_TYPE_LOCATION = 'location';
    // 事件类型常量
    const MSG_EVENT_SCAN = 'SCAN';
    const MSG_EVENT_CLICK = 'CLICK';
    const MSG_EVENT_LOCATION = 'LOCATION';
    const MSG_EVENT_SUBSCRIBE = 'subscribe';
    const MSG_EVENT_MASSSENDJOBFINISH = 'MASSSENDJOBFINISH';
    // 微信推送过来的数据
    private $data = array();

    /**
     * 构造方法，用于实例化微信SDK，自动回复消息时实例化该SDK
     * @param string $token 微信后台填写的TOKEN
     */
    public function __construct($token){
        $token and self::auth($token) and ($x = input('get.echostr/s')) and exit($x);
    }

    /**
     * 获取微信推送的数据
     * @return array 转换为数组后的数据
     */
    public function request(){
        $xml = false;
        preg_match('/^Mozilla\/\d\.\d$/', $_SERVER['HTTP_USER_AGENT']) and request()->isPost() and ($xml = file_get_contents("php://input")) and ($xml = new \SimpleXMLElement($xml));
        if($xml && is_array($xml)){
            foreach($xml as $k => $v){
                $this->data[$k] = strval($v);
            }
            return $this->data;
        }else{
            return false;
        }
    }

    /**
     * 响应微信发送的信息（自动回复）
     * @param  array  $content 回复信息，文本信息为string类型
     * @param  string $type    消息类型
     */
    public function response($content, $type = self::MSG_TYPE_TEXT){
        // 基础数据
        $data = array(
            'ToUserName' => $this->data['FromUserName'],
            'FromUserName' => $this->data['ToUserName'],
            'CreateTime' => time(),
            'MsgType' => $type
        );
        // 按类型添加额外数据
        $content = call_user_func([
            'Wechat',
            $type
        ], $content);
        if($type == self::MSG_TYPE_TEXT || $type == self::MSG_TYPE_NEWS){
            $data = array_merge($data, $content);
        }else{
            $data[ucfirst($type)] = $content;
        }
        // 转换数据为XML
        $xml = new \SimpleXMLElement('<xml></xml>');
        self::data2xml($xml, $data);
        exit($xml->asXML());
    }

    /**
     * 回复文本消息
     * @param  string $text   回复的文字
     */
    public function replyText($text){
        return $this->response($text, self::MSG_TYPE_TEXT);
    }

    /**
     * 回复图片消息
     * @param  string $media_id 图片ID
     */
    public function replyImage($media_id){
        return $this->response($media_id, self::MSG_TYPE_IMAGE);
    }

    /**
     * 回复语音消息
     * @param  string $media_id 音频ID
     */
    public function replyVoice($media_id){
        return $this->response($media_id, self::MSG_TYPE_VOICE);
    }

    /**
     * 回复视频消息
     * @param  string $media_id    视频ID
     * @param  string $title       视频标题
     * @param  string $discription 视频描述
     */
    public function replyVideo($media_id, $title, $discription){
        return $this->response(func_get_args(), self::MSG_TYPE_VIDEO);
    }

    /**
     * 回复音乐消息
     * @param  string $title          音乐标题
     * @param  string $discription    音乐描述
     * @param  string $musicurl       音乐链接
     * @param  string $hqmusicurl     高品质音乐链接
     * @param  string $thumb_media_id 缩略图ID
     */
    public function replyMusic($title, $discription, $musicurl, $hqmusicurl, $thumb_media_id){
        return $this->response(func_get_args(), self::MSG_TYPE_MUSIC);
    }

    /**
     * 回复图文消息，一个参数代表一条信息
     * @param  array  $news   图文内容 [标题，描述，URL，缩略图]
     * @param  array  $news1  图文内容 [标题，描述，URL，缩略图]
     * @param  array  $news2  图文内容 [标题，描述，URL，缩略图]
     *                ...     ...
     * @param  array  $news9  图文内容 [标题，描述，URL，缩略图]
     */
    public function replyNews($news, $news1, $news2, $news3){
        return $this->response(func_get_args(), self::MSG_TYPE_NEWS);
    }

    /**
     * 回复一条图文消息
     * @param  string $title       文章标题
     * @param  string $discription 文章简介
     * @param  string $url         文章连接
     * @param  string $picurl      文章缩略图
     */
    public function replyNewsOnce($title, $discription, $url, $picurl){
        return $this->response(array(
            func_get_args()
        ), self::MSG_TYPE_NEWS);
    }

    /**
     * 数据XML编码
     * @param  object $xml  XML对象
     * @param  mixed  $data 数据
     * @param  string $item 数字索引时的节点名称
     * @return string
     */
    protected static function data2xml($xml, $data, $item = 'item'){
        foreach($data as $key => $value){
            // 指定默认的数字key
            is_numeric($key) and ($key = $item);
            // 添加子元素
            if(is_array($value) || is_object($value)){
                $child = $xml->addChild($key);
                self::data2xml($child, $value, $item);
            }else{
                if(is_numeric($value)){
                    $child = $xml->addChild($key, $value);
                }else{
                    $child = $xml->addChild($key);
                    $node = dom_import_simplexml($child);
                    $cdata = $node->ownerDocument->createCDATASection($value);
                    $node->appendChild($cdata);
                }
            }
        }
    }

    /**
     * 对数据进行签名认证，确保是微信发送的数据
     * @param  string $token 微信开放平台设置的TOKEN
     * @return boolean       true-签名正确，false-签名错误
     */
    protected static function auth($token){
        $timestamp = input('get.timestamp/s', '');
        $nonce = input('get.nonce/s', '');
        $sign = input('get.signature/s', '');
        if(empty($timestamp) || empty($nonce) || empty($sign)) return false;
        // 获取数据
        $data = array(
            $timestamp,
            $nonce,
            $token
        );
        // 对数据进行字典排序
        sort($data, SORT_STRING);
        // 生成签名
        $signature = sha1(implode($data));
        return $signature === $sign;
    }

    /**
     * 构造文本信息
     * @param  string $content 要回复的文本
     */
    private static function text($content){
        $data['Content'] = $content;
        return $data;
    }

    /**
     * 构造图片信息
     * @param  integer $media 图片ID
     */
    private static function image($media){
        $data['MediaId'] = $media;
        return $data;
    }

    /**
     * 构造音频信息
     * @param  integer $media 语音ID
     */
    private static function voice($media){
        $data['MediaId'] = $media;
        return $data;
    }

    /**
     * 构造视频信息
     * @param  array $video 要回复的视频 [视频ID，标题，说明]
     */
    private static function video($video){
        $data = array();
        list($data['MediaId'], $data['Title'], $data['Description'], ) = $video;
        return $data;
    }

    /**
     * 构造音乐信息
     * @param  array $music 要回复的音乐[标题，说明，链接，高品质链接，缩略图ID]
     */
    private static function music($music){
        $data = array();
        list($data['Title'], $data['Description'], $data['MusicUrl'], $data['HQMusicUrl'], $data['ThumbMediaId'], ) = $music;
        return $data;
    }

    /**
     * 构造图文信息
     * @param  array $news 要回复的图文内容
     * [
     *      0 => 第一条图文信息[标题，说明，图片链接，全文连接]，
     *      1 => 第二条图文信息[标题，说明，图片链接，全文连接]，
     *      2 => 第三条图文信息[标题，说明，图片链接，全文连接]，
     * ]
     */
    private static function news($news){
        $articles = array();
        foreach($news as $key => $value){
            list($articles[$key]['Title'], $articles[$key]['Description'], $articles[$key]['Url'], $articles[$key]['PicUrl']) = $value;
            // 最多只允许10条图文信息
            if($key >= 9) break;
        }
        $data['ArticleCount'] = count($articles);
        $data['Articles'] = $articles;
        return $data;
    }
}

/**
 * 微信认证相关的工具类
 */
class WechatAuth{
    // 消息类型常量
    const MSG_TYPE_TEXT = 'text';
    const MSG_TYPE_IMAGE = 'image';
    const MSG_TYPE_VOICE = 'voice';
    const MSG_TYPE_VIDEO = 'video';
    const MSG_TYPE_MUSIC = 'music';
    const MSG_TYPE_NEWS = 'news';
    const MSG_TYPE_LOCATION = 'location';
    const MSG_TYPE_LINK = 'link';
    const MSG_TYPE_EVENT = 'event';
    // 二维码类型常量
    const QR_SCENE = 'QR_SCENE';
    const QR_LIMIT_SCENE = 'QR_LIMIT_SCENE';
    // 微信开发者申请的appID
    private $appId = '';
    // 微信开发者申请的appSecret
    private $appSecret = '';
    // 获取到的access_token
    private $accessToken = '';
    // 微信api根路径
    private $apiURL = 'https://api.weixin.qq.com/cgi-bin';
    // 认证api根目录
    private $oauthApiURL = 'https://api.weixin.qq.com/sns';
    // 微信二维码根路径
    private $qrcodeURL = 'https://mp.weixin.qq.com/cgi-bin';
    // 微信媒体文件根路径
    private $mediaURL = 'http://file.api.weixin.qq.com/cgi-bin';
    // 认证CODE请求链接
    private $requestCodeURL = 'https://open.weixin.qq.com/connect/oauth2/authorize';

    /**
     * 构造方法，调用微信高级接口时实例化SDK
     * @param string $appid  微信appid
     * @param string $secret 微信appsecret
     * @param string $token  获取到的access_token
     */
    public function __construct($appid, $secret){
        if($appid && $secret){
            $this->appId = $appid;
            $this->appSecret = $secret;
            $this->accessToken = ($x = cache('wx_access_token')) ? $x : (($x = $this->getAccessToken()) && $x['access_token'] ? $x['access_token'] : false);
            empty($this->accessToken) and exit() or cache('wx_access_token', $this->accessToken, 7100);
        }else{
            trace('微信：配置的APPID & APPSECRET错误', 'error');
        }
    }

    public function getRequestCodeURL($redirect_uri, $state = null, $scope = 'snsapi_userinfo'){
        $query = array(
            'appid' => $this->appId,
            'redirect_uri' => $redirect_uri,
            'response_type' => 'code',
            'scope' => $scope
        );
        if(!is_null($state) && preg_match('/[a-zA-Z0-9]+/', $state)) $query['state'] = $state;
        $query = http_build_query($query);
        return "{$this->requestCodeURL}?{$query}#wechat_redirect";
    }

    /**
     * 获取access_token，用于后续接口访问
     * @return array access_token信息，包含 token 和有效期
     */
    public function getAccessToken($type = 'client', $code = null){
        $param = array(
            'appid' => $this->appId,
            'secret' => $this->appSecret
        );
        switch($type){
            case 'client':
                $param['grant_type'] = 'client_credential';
                $url = "{$this->apiURL}/token";
                break;
            case 'code':
                $param['code'] = $code;
                $param['grant_type'] = 'authorization_code';
                $url = "{$this->oauthApiURL}/oauth2/access_token";
                break;
            default:
                trace('微信：不支持的grant_type类型', 'error');
                break;
        }
        $token = self::http($url, $param);
        $token = json_decode($token, true);
        if(is_array($token)){
            if(isset($token['errcode'])){
                trace("微信：获取ACCESS_TOKEN失败【{$token['errmsg']}】", 'error');
            }else{
                $this->accessToken = $token['access_token'];
                return $token;
            }
        }else{
            trace("微信：获取ACCESS_TOKEN失败【请求异常】", 'error');
        }
        return false;
    }

    /**
     * 获取授权用户信息
     * @param  string $token acess_token
     * @param  string $lang  指定的语言
     * @return array         用户信息数据，具体参见微信文档
     */
    public function getUserInfo($token, $lang = 'zh_CN'){
        $query = array(
            'access_token' => $token['access_token'],
            'openid' => $token['openid'],
            'lang' => $lang
        );
        $info = self::http("{$this->oauthApiURL}/userinfo", $query);
        return json_decode($info, true);
    }

    /**
     * 上传媒体资源
     * @param  string $filename 媒体资源本地路径
     * @param  string $type     媒体资源类型，具体请参考微信开发手册
     */
    public function mediaUpload($filename, $type){
        $param = array(
            'access_token' => $this->accessToken,
            'type' => $type
        );
        $filename = realpath($filename);
        if($filename){
            $file = array(
                'media' => "@{$filename}"
            );
            $url = "{$this->mediaURL}/media/upload";
            $data = self::http($url, $param, $file, 'POST');
            return json_decode($data, true);
        }else{
            trace('微信：文件获取失败', 'error');
        }
    }

    /**
     * 获取媒体资源下载地址
     * 注意：视频资源不允许下载
     * @param  string $media_id 媒体资源id
     * @return string           媒体资源下载地址
     */
    public function mediaGet($media_id){
        $param = array(
            'access_token' => $this->accessToken,
            'media_id' => $media_id
        );
        $url = "{$this->mediaURL}/media/get?";
        return $url . http_build_query($param);
    }

    /**
     * 给指定用户推送信息
     * 注意：微信规则只允许给在48小时内给公众平台发送过消息的用户推送信息
     * @param  string $openid  用户的openid
     * @param  array  $content 发送的数据，不同类型的数据结构可能不同
     * @param  string $type    推送消息类型
     */
    public function messageCustomSend($openid, $content, $type = self::MSG_TYPE_TEXT){
        // 基础数据
        $data = array(
            'touser' => $openid,
            'msgtype' => $type
        );
        // 根据类型附加额外数据
        $data[$type] = call_user_func(array(
            'WechatAuth',
            $type
        ), $content);
        return $this->api('message/custom/send', $data);
    }

    /**
     * 发送文本消息
     * @param  string $openid 用户的openid
     * @param  string $text   发送的文字
     */
    public function sendText($openid, $text){
        return $this->messageCustomSend($openid, $text, self::MSG_TYPE_TEXT);
    }

    /**
     * 发送图片消息
     * @param  string $openid 用户的openid
     * @param  string $media  图片ID
     */
    public function sendImage($openid, $media){
        return $this->messageCustomSend($openid, $media, self::MSG_TYPE_IMAGE);
    }

    /**
     * 发送语音消息
     * @param  string $openid 用户的openid
     * @param  string $media  音频ID
     */
    public function sendVoice($openid, $media){
        return $this->messageCustomSend($openid, $media, self::MSG_TYPE_VOICE);
    }

    /**
     * 发送视频消息
     * @param  string $openid      用户的openid
     * @param  string $media_id    视频ID
     * @param  string $title       视频标题
     * @param  string $discription 视频描述
     */
    public function sendVideo(){
        $video = func_get_args();
        $openid = array_shift($video);
        return $this->messageCustomSend($openid, $video, self::MSG_TYPE_VIDEO);
    }

    /**
     * 发送音乐消息
     * @param  string $openid         用户的openid
     * @param  string $title          音乐标题
     * @param  string $discription    音乐描述
     * @param  string $musicurl       音乐链接
     * @param  string $hqmusicurl     高品质音乐链接
     * @param  string $thumb_media_id 缩略图ID
     */
    public function sendMusic(){
        $music = func_get_args();
        $openid = array_shift($music);
        return $this->messageCustomSend($openid, $music, self::MSG_TYPE_MUSIC);
    }

    /**
     * 发送图文消息
     * @param  string $openid 用户的openid
     * @param  array  $news   图文内容 [标题，描述，URL，缩略图]
     * @param  array  $news1  图文内容 [标题，描述，URL，缩略图]
     * @param  array  $news2  图文内容 [标题，描述，URL，缩略图]
     *                ...     ...
     * @param  array  $news9  图文内容 [标题，描述，URL，缩略图]
     */
    public function sendNews(){
        $news = func_get_args();
        $openid = array_shift($news);
        return $this->messageCustomSend($openid, $news, self::MSG_TYPE_NEWS);
    }

    /**
     * 发送一条图文消息
     * @param  string $openid      用户的openid
     * @param  string $title       文章标题
     * @param  string $discription 文章简介
     * @param  string $url         文章连接
     * @param  string $picurl      文章缩略图
     */
    public function sendNewsOnce(){
        $news = func_get_args();
        $openid = array_shift($news);
        $news = array(
            $news
        );
        return $this->messageCustomSend($openid, $news, self::MSG_TYPE_NEWS);
    }

    /**
     * 创建用户组
     * @param  string $name 组名称
     */
    public function groupsCreate($name){
        $data = array(
            'group' => array(
                'name' => $name
            )
        );
        return $this->api('groups/create', $data);
    }

    /**
     * 查询所有分组
     * @return array 分组列表
     */
    public function groupsGet(){
        return $this->api('groups/get', '', 'GET');
    }

    /**
     * 查询用户所在的分组
     * @param  string $openid 用户的OpenID
     * @return number         分组ID
     */
    public function groupsGetid($openid){
        $data = array(
            'openid' => $openid
        );
        return $this->api('groups/getid', $data);
    }

    /**
     * 修改分组
     * @param  number $id   分组ID
     * @param  string $name 分组名称
     * @return array        修改成功或失败信息
     */
    public function groupsUpdate($id, $name){
        $data = array(
            'id' => $id,
            'name' => $name
        );
        return $this->api('groups/update', $data);
    }

    /**
     * 移动用户分组
     * @param  string $openid     用户的OpenID
     * @param  number $to_groupid 要移动到的分组ID
     * @return array              移动成功或失败信息
     */
    public function groupsMemberUpdate($openid, $to_groupid){
        $data = array(
            'openid' => $openid,
            'to_groupid' => $to_groupid
        );
        return $this->api('groups/member/update', $data);
    }

    /**
     * 用户设备注名
     * @param  string $openid 用户的OpenID
     * @param  string $remark 设备注名
     * @return array          执行成功失败信息
     */
    public function userInfoUpdateremark($openid, $remark){
        $data = array(
            'openid' => $openid,
            'remark' => $remark
        );
        return $this->api('user/info/updateremark', $data);
    }

    /**
     * 获取指定用户的详细信息
     * @param  string $openid 用户的openid
     * @param  string $lang   需要获取数据的语言
     */
    public function userInfo($openid, $lang = 'zh_CN'){
        $param = array(
            'openid' => $openid,
            'lang' => $lang
        );
        return $this->api('user/info', '', 'GET', $param);
    }

    /**
     * 获取关注者列表
     * @param  string $next_openid 下一个openid，在用户数大于10000时有效
     * @return array               用户列表
     */
    public function userGet($next_openid = ''){
        $param = array(
            'next_openid' => $next_openid
        );
        return $this->api('user/get', '', 'GET', $param);
    }

    /**
     * 创建自定义菜单
     * @param  array $button 符合规则的菜单数组，规则参见微信手册
     */
    public function menuCreate($button){
        $data = array(
            'button' => $button
        );
        return $this->api('menu/create', $data);
    }

    /**
     * 获取所有的自定义菜单
     * @return array  自定义菜单数组
     */
    public function menuGet(){
        return $this->api('menu/get', '', 'GET');
    }

    /**
     * 删除自定义菜单
     */
    public function menuDelete(){
        return $this->api('menu/delete', '', 'GET');
    }

    /**
     * 创建二维码，可创建指定有效期的二维码和永久二维码
     * @param  integer $scene_id       二维码参数
     * @param  integer $expire_seconds 二维码有效期，0-永久有效
     */
    public function qrcodeCreate($scene_id, $expire_seconds = 0){
        $data = array();
        if(is_numeric($expire_seconds) && $expire_seconds > 0){
            $data['expire_seconds'] = $expire_seconds;
            $data['action_name'] = self::QR_SCENE;
        }else{
            $data['action_name'] = self::QR_LIMIT_SCENE;
        }
        $data['action_info']['scene']['scene_id'] = $scene_id;
        return $this->api('qrcode/create', $data);
    }

    /**
     * 根据ticket获取二维码URL
     * @param  string $ticket 通过 qrcodeCreate接口获取到的ticket
     * @return string         二维码URL
     */
    public function showqrcode($ticket){
        return "{$this->qrcodeURL}/showqrcode?ticket={$ticket}";
    }

    /**
     * 长链接转短链接
     * @param  string $long_url 长链接
     * @return string           短链接
     */
    public function shorturl($long_url){
        $data = array(
            'action' => 'long2short',
            'long_url' => $long_url
        );
        return $this->api('shorturl', $data);
    }

    /**
     * 调用微信api获取响应数据
     * @param  string $name   API名称
     * @param  string $data   POST请求数据
     * @param  string $method 请求方式
     * @param  string $param  GET请求参数
     * @return array          api返回结果
     */
    protected function api($name, $data = '', $method = 'POST', $param = ''){
        $params = array(
            'access_token' => $this->accessToken
        );
        if(!empty($param) && is_array($param)){
            $params = array_merge($params, $param);
        }
        $url = "{$this->apiURL}/{$name}";
        if(!empty($data)){
            // 保护中文，微信api不支持中文转义的json结构
            array_walk_recursive($data, function (&$value){
                $value = urlencode($value);
            });
            $data = urldecode(json_encode($data));
        }
        $data = self::http($url, $params, $data, $method);
        return json_decode($data, true);
    }

    /**
     * 发送HTTP请求方法，目前只支持CURL发送请求
     * @param  string $url    请求URL
     * @param  array  $param  GET参数数组
     * @param  array  $data   POST的数据，GET请求时该参数无效
     * @param  string $method 请求方法GET/POST
     * @return array          响应数据
     */
    protected static function http($url, $param, $data = '', $method = 'GET'){
        $opts = array(
            CURLOPT_TIMEOUT => 30,
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false
        );
        /* 根据请求类型设置特定参数 */
        $opts[CURLOPT_URL] = $url . '?' . http_build_query($param);
        if(strtoupper($method) == 'POST'){
            $opts[CURLOPT_POST] = 1;
            $opts[CURLOPT_POSTFIELDS] = $data;
            if(is_string($data)){ // 发送JSON数据
                $opts[CURLOPT_HTTPHEADER] = array(
                    'Content-Type: application/json; charset=utf-8',
                    'Content-Length: ' . strlen($data)
                );
            }
        }
        /* 初始化并执行curl请求 */
        $ch = curl_init();
        curl_setopt_array($ch, $opts);
        $data = curl_exec($ch);
        $error = curl_error($ch);
        curl_close($ch);
        // 发生错误，抛出异常
        if($error) throw new \Exception('请求发生错误：' . $error);
        return $data;
    }

    /**
     * 构造文本信息
     * @param  string $content 要回复的文本
     */
    private static function text($content){
        $data['content'] = $content;
        return $data;
    }

    /**
     * 构造图片信息
     * @param  integer $media 图片ID
     */
    private static function image($media){
        $data['media_id'] = $media;
        return $data;
    }

    /**
     * 构造音频信息
     * @param  integer $media 语音ID
     */
    private static function voice($media){
        $data['media_id'] = $media;
        return $data;
    }

    /**
     * 构造视频信息
     * @param  array $video 要回复的视频 [视频ID，标题，说明]
     */
    private static function video($video){
        $data = array();
        list($data['media_id'], $data['title'], $data['description'], ) = $video;
        return $data;
    }

    /**
     * 构造音乐信息
     * @param  array $music 要回复的音乐[标题，说明，链接，高品质链接，缩略图ID]
     */
    private static function music($music){
        $data = array();
        list($data['title'], $data['description'], $data['musicurl'], $data['hqmusicurl'], $data['thumb_media_id'], ) = $music;
        return $data;
    }

    /**
     * 构造图文信息
     * @param  array $news 要回复的图文内容
     * [
     *      0 => 第一条图文信息[标题，说明，图片链接，全文连接]，
     *      1 => 第二条图文信息[标题，说明，图片链接，全文连接]，
     *      2 => 第三条图文信息[标题，说明，图片链接，全文连接]，
     * ]
     */
    private static function news($news){
        $articles = array();
        foreach($news as $key => $value){
            list($articles[$key]['title'], $articles[$key]['description'], $articles[$key]['url'], $articles[$key]['picurl']) = $value;
            // 最多只允许10条图文信息
            if($key >= 9) break;
        }
        $data['articles'] = $articles;
        return $data;
    }
}
